Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: non-singleton EppoJSClient #166

Open
wants to merge 30 commits into
base: typo/no-singleton
Choose a base branch
from
Open

Conversation

typotter
Copy link
Collaborator

@typotter typotter commented Jan 31, 2025


labels: mergeable

Eppo Internal
🎟️ Fixes: FF-3888
πŸ“œ Multiple Eppo Clients
Configuration Side-loading

Motivation and Context

The init and getInstance() methods work on a singleton instance of the EppoClient which makes for a generally smooth developer experience given that the Eppo SDK essentially handles dependency management for the dev. This does have its own drawbacks, but those are not particularly relevant here.

There are use cases, however, when a non-singleton instance of the EppoClient is required. One such use case is embedding the Eppo SDK into a library which itself can be included in other applications. If that 3p library shipped with Eppo is integrated into another application that has the Eppo SDK running, there would be undefined behaviour as the SDK cannot handle this use case.

The init method (and class constructors) for EppoClient and subclasses have evolved organically over time, adding new options as new features are added to the clients. The very large options type is beginning to become a little untenable and disorganized so we take the opportunity to clean that up a bit here.

There are other limitations and drawbacks to the current model of instantiating an EppoClient statically and then initializing it when the code calls init including the awkward coupling of needing to wait for init to resolve in order to get a reference to an initialized client. We have an opportunity to decouple initialization and waiting to make for a better DX (in addition to giving the dev full control over managing the EppoClient reference (intrinsically allows for easier mocking in tests and will be consistent with the host applications existing DI approach).

This change must be done without a major version bump and completely preserve the existing singleton API

Description

  • First, refactor the initialization options by grouping related options and extracting different types for each.
  • Each option type is then combined back into a type using the existing IClientConfig name (this keeps the change backwards compatible while offering a big win in option clarity)
  • Move the static reference initialized to non static.
  • Accept an EppoJSClient instance param instead of accessing though EppoJSClient.instance where applicable
  • Where the static instance must be accessed, change to getInstance
  • Allow the EppoJSClient constructor to accept the full options object. When full options are passed, kick off initializing the client. Otherwise, follow the "old" contstructor path
  • Docs

How has this been documented?

How has this been tested?

  • tests

package.json Outdated Show resolved Hide resolved
@typotter typotter changed the title Tp/namespace feat: non-singleton EppoJSClient Jan 31, 2025
src/i-client-config.ts Outdated Show resolved Hide resolved
src/index.ts Outdated
public static instance = new EppoJSClient({
flagConfigurationStore,
isObfuscated: true,
});
public static initialized = false;
initialized = false;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move static members to the instance

src/index.ts Outdated
}
}

public waitForReady(): Promise<void> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new method! πŸŽ‰

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does "ready" mean in this context? adding comments is likely to help, also we should always do it for public APIs anyways

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed to waitForInitialization

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

waitForConfiguration to be in line with multiplatform's analog PollerThread::waitForConfiguration

src/index.ts Outdated Show resolved Hide resolved
src/index.ts Outdated
if (!EppoJSClient.initialized) {
private ensureInitialized() {
if (!this.initialized) {
// TODO: check super.isInitialized?
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EppoClient.initialized also exists, but means something different than it does here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does it mean and how is that different than this? consider renaming either if so

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the super method checks that all the config stores are initialized while this this.initialized is true when all the initialization workload is done so they are pretty much the same.
I think we can move to deprecate EppoJSClient.initialized and defer to super.isInitialized().

src/index.ts Outdated
Comment on lines 339 to 380
/**
* Tracks pending initialization. After an initialization completes, the value is removed from the map.
*/
private static initializationPromise: Promise<EppoJSClient> | null = null;

/**
* This method is part of a bridge from using a singleton to independent instances. More specifically, it exists so
* that the init method can access the private field, `readyResolver`. It should not be called by any
* methods other than the `init` method. There are limited guards in place; the behaviour if called inappropriately
* is undefined.
*
* It also keeps code that relies on internal details of EppoJSClient colocated in the class.
*
* @internal
*
* @param client
* @param config
*/
static async initializeClient(
client: EppoJSClient,
config: IClientConfig,
): Promise<EppoJSClient> {
validation.validateNotBlank(config.apiKey, 'API key required');

// If there is already an init in progress for this apiKey, return that.
if (!EppoJSClient.initializationPromise) {
EppoJSClient.initializationPromise = explicitInit(config, client).then((client) => {
// Resolve the ready promise if it exists
if (client.readyPromiseResolver) {
client.readyPromiseResolver();
client.readyPromiseResolver = null;
}
return client;
});
}

const readyClient = await EppoJSClient.initializationPromise;
EppoJSClient.initializationPromise = null;

return readyClient;
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the old init method and initialization buffer tracker.
Moved the method to a static member of EppoJSClient so that it can access the readyResolver and notify waitForReady

src/index.ts Outdated Show resolved Hide resolved
src/index.ts Outdated Show resolved Hide resolved
/**
* Initializes the Eppo client with configuration parameters.
* This method should be called once on application startup.
* If an initialization is in process, calling `init` will return the in-progress
* `Promise<EppoClient>`. Once the initialization completes, calling `init` again will kick off the
* initialization routine (if `forceReinitialization` is `true`).
*
*
* @deprecated
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

controversial, I know.
willing to negotiate

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a minor change for the end user if the argument interface stays identical

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @deprecated annotation is here to help push devs to using the new construct + wait paradigm. This method will be removed and the argument interface for the new constructor is slightly different sdkKey vs apiKey

* @param config - client configuration
* @public
*/
export async function init(config: IClientConfig): Promise<EppoClient> {
validation.validateNotBlank(config.apiKey, 'API key required');
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to EppoJSClient

src/index.ts Outdated Show resolved Hide resolved
src/index.ts Outdated Show resolved Hide resolved
Copy link
Collaborator Author

@typotter typotter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks everyone for the great comments and perspectives.

Refactored back to a little closer to where we started; dropped all the dual-constructor/config stuff.

src/index.ts Outdated
public static initialized = false;
initialized = false;

constructor(optionsOrConfig: EppoClientParameters | IClientOptions) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor is already public, so we needed to keep the EppoClientParameters in the constructor to avoid a major change.

That being said, I moved to a static builder method instead that lets us get away from the dual constructor.

/**
* Initializes the Eppo client with configuration parameters.
* This method should be called once on application startup.
* If an initialization is in process, calling `init` will return the in-progress
* `Promise<EppoClient>`. Once the initialization completes, calling `init` again will kick off the
* initialization routine (if `forceReinitialization` is `true`).
*
*
* @deprecated
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @deprecated annotation is here to help push devs to using the new construct + wait paradigm. This method will be removed and the argument interface for the new constructor is slightly different sdkKey vs apiKey

src/index.ts Outdated Show resolved Hide resolved

/** Configuration settings for the event dispatcher */
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

src/index.ts Outdated
return client;
}

async init(config: IClientConfig): Promise<EppoJSClient> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the old explicitInit method

Comment on lines 247 to 251
export type IClientOptions = IApiOptions &
ILoggers &
IEventOptions &
IStorageOptions &
IPollingOptions;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is that a dev could build different options objects then combine them for initialization. If they don't need custom event and storage options, for example, they wouldn't have those configs.

Comment on lines 27 to 41
// Always include configuration request parameters
parameters.configurationRequestParameters = {
apiKey: options.sdkKey,
sdkVersion, // dynamically picks up version.
sdkName, // Hardcoded to `js-client-sdk`
baseUrl: options.baseUrl,
requestTimeoutMs: options.requestTimeoutMs,
numInitialRequestRetries: options.numInitialRequestRetries,
numPollRequestRetries: options.numPollRequestRetries,
pollAfterSuccessfulInitialization: options.pollAfterSuccessfulInitialization,
pollAfterFailedInitialization: options.pollAfterFailedInitialization,
pollingIntervalMs: options.pollingIntervalMs,
throwOnFailedInitialization: options.throwOnFailedInitialization,
skipInitialPoll: options.skipInitialRequest,
};
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

* Configuration for regular client initialization
* @public
*/
export type IClientConfig = Omit<IClientOptions, 'sdkKey' | 'offline'> &
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed up. moved sdkKey to the next major

it('should be independent of the singleton', async () => {
const apiOptions: IApiOptions = { sdkKey: '<MY SDK KEY>' };
const options: IClientOptions = { ...apiOptions, assignmentLogger: mockLogger };
const isolatedClient = new EppoJSClient(options);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to a builder

src/index.ts Outdated
this.readyPromiseResolver = resolve;
});
} else {
this.readyPromise = explicitInit(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

init handles catching exceptions on init and throwing/logging depending on the option

Copy link

@rasendubi rasendubi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing constructor and init() method publicly is my only major concern.

The better handling of re-initialization is nice to have and we can implement it in future PRs (though it's a good idea to remove forceInitialize option now to avoid a breaking change in the future)

src/i-client-config.ts Outdated Show resolved Hide resolved
src/index.ts Outdated
isObfuscated: true,
});
public static initialized = false;

constructor(options: EppoClientParameters) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we keep constructor private now that we use static builders?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the constructor private. πŸ₯‚ to progress

src/index.ts Outdated
Comment on lines 146 to 152
/**
* Resolved when the client is initialized
* @private
*/
private readonly initializedPromise: Promise<void>;

initialized = false;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: shall we move these fields (together with initializedPromiseResolver) above the constructor? It's too common to group all class fields in one fields that it becomes surprising when extra fields are "hidden" between other methods

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

src/index.ts Outdated

initialized = false;

async init(config: IClientConfig): Promise<EppoJSClient> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to keep init private / package-only?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do, but we can't since we need to call into it from the legacy global init method. I have marked with @internal and have tried to keep the difference between calling the global init and this class method minimal, yet with a reasonable separation of concerns. When we make a major bump, we can properly privatize this method and delete the global init.

src/index.ts Outdated
Comment on lines 189 to 192
// If the instance was polling, stop.
this.stopPolling();
// Set up assignment logger and cache
this.setAssignmentLogger(config.assignmentLogger);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: again, unrelated to the PR per se and good to handle in the future β€” we're actually in a good position now to fix all re-initialization bugs and race conditions by re-creating a client instead of doing partial re-init.

tl;dr on the bugs: most client components are not designed to be stopped and re-started with a different configuration. In particular, the poller does not handle it well and stopPolling stops future requests but not in-progress ones, so it may still set unexpected configuration in the store. It might be fixed with some extra checks but I still wouldn't trust the client as a whole to handle re-initialization, especially because creating a new client and discarding the old one is now extremely easy

More specific proposal:

  • Keep client's constructor and init methods private/package-local. (Constructor is exposing internal/unsafe configuration so we don't want users calling it. init makes it too easy too shoot yourself in the foot by re-initializing the client, so I wouldn't give this gun to users either.)
  • Re-initialization is disallowed on client. (If constructor and init methods are private, we don't need to check for that.)
  • The global init function should handle forceReinitialize by creating a new singleton and stopping the old one. This is to keep backward compatibility but also because it's the only case where this makes sense.
  • I would argue that we should deprecate forceReinitialize in global init β€” if users need re-configuring the client on the fly, they are venturing into the advanced territory and using standalone clients is easier / less error-prone.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The force reinitialize is heavily used internally in tests to get around the singleton pattern, so it's super easy to get onboard with your proposal here -- deprecate and remove from the new builder

The global init function should handle forceReinitialize by creating a new singleton and stopping the old one. This is to keep backward compatibility but also because it's the only case where this makes sense.

Wouldn't creating a new instance for the singleton make any existing reference to the singleton invalid?
The initialization routine swaps out the config stores and requestor; doesn't this prevent the potential overwrite? (If both configstores are using a persistent store keyed by SDK key, they'd overwrite into the persistent store, but then that's only read on initialization if it's not expired, so there is a corner case).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

came as close to your proposal as possible; stayed short of swapping out the singleton instance, but I reason that the fetchFlagConfigurations does a proper job of stopping the existing polling and swapping out the config-requesting mechanisms to grab the new request parameters.
We do still have the issue of an in-flight request being able to overwrite after it's been stopped.
I opened FF-3996 to track this as it's ultimately a bug in commons

src/index.ts Outdated
Comment on lines 638 to 639
if (initializationPromise === null) {
initializationPromise = getInstance().init(config);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: again, unrelated to the PR per se but I find this initializationPromise disturbing and a potential source of errors.

If init() is called with different configuration and forceReinitialize: true, the second call is going to be silently discarded. Which may lead to all sorts of troubles as client is now running with unexpected configuration.

With forceReinitialize: false, this handling will also silence a warning, so users have less opportunity to catch their bug.

At the very least, the case of concurrent initialization is worth a warning. But overall, I think it might be a good idea to handle forceReinitialize here in this function (as per my other comment)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "buffered" initialization was implemented because of massive amounts of traffic from one client. It is one of several measures taken to prevent the flood of requests from improper initialization of the eppo client (ex: calling init everywhere instead of getInstance).

I've added a warning here whenever init is called but the singleton instance is already initialized (or in progress).

Comment on lines 121 to 124
/**
* Force reinitialize the SDK if it is already initialized.
*/
forceReinitialize?: boolean;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per my other comment, I propose to forbid re-initialization of standalone clients (because it's buggy and it's now possible to create new clients). While we can postpone the implementation of that, I believe that buildAndInit should not accept forceReinitialize already (as removing it later will be a major change)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.
Pushed the forceReinit check to the soon-to-be-deprecated global static init method. Also moved this option to ICompatibilityOptions (maybe better named LegacyOptions?)

@typotter typotter changed the base branch from main to typo/no-singleton February 13, 2025 20:13
src/index.ts Outdated Show resolved Hide resolved
@typotter typotter requested a review from rasendubi February 14, 2025 17:09
@typotter
Copy link
Collaborator Author

Several false starts and iterations later, we're ready for another review. Note: this PR is now stacked on #170 where changes are made just to how the singleton is referenced, leaving the focus of this PR on waitForConfiguration and buildAndInit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants